package bancosys.tec.persist.hibernate.impl;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.hibernate.EntityMode;
import org.hibernate.SessionFactory;
import org.hibernate.engine.CascadeStyle;
import org.hibernate.engine.CascadingAction;
import org.hibernate.engine.SessionFactoryImplementor;
import org.hibernate.metadata.ClassMetadata;
import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.entity.AbstractEntityPersister;
import org.hibernate.persister.entity.Joinable;
import org.hibernate.persister.entity.OuterJoinLoadable;
import org.hibernate.type.AssociationType;
import org.hibernate.type.CollectionType;
import org.hibernate.type.ForeignKeyDirection;
import org.hibernate.type.Type;

import bancosys.tec.persist.authorization.AuthorizationContext;
import bancosys.tec.persist.hibernate.PartitionSessionFactory;

/**
 * Classe para extrair informacoes sobre as classes persistiveis e suas dependencias
 */
public class HibernateDependencyMetaData {

    private Map<Class<?>, Collection<Class<?>>> subclassMapping = new HashMap<Class<?>, Collection<Class<?>>>();

    private SessionFactory sessionFactory;

    /**
     * Formato do banList EntidadeElemento.tabela[colunas]
     */
    private Set<String> banList = new HashSet<String>();

    private Map<String, List<HibernateEntityDependency>> dependencies = new HashMap<String, List<HibernateEntityDependency>>();

    /**
     * C'tor
     */
    public HibernateDependencyMetaData() {
        // vazio
    }

    /**
     * C'tor
     * 
     * @param sessioNFactory a {@link SessionFactory}
     */
    public HibernateDependencyMetaData(final SessionFactory sessioNFactory) {
        this.sessionFactory = normalizePartitionSessionFactory(sessioNFactory);
        this.buildDependencyMetaData();
    }

    /**
     * Extrai o sessionFactory do partition session factory
     * 
     * @param sessionFactory {@link SessionFactory}
     * @return {@link SessionFactory}
     */
    private static SessionFactory normalizePartitionSessionFactory(SessionFactory sessionFactory) {
        if (sessionFactory instanceof PartitionSessionFactory) {
            return ((PartitionSessionFactory) sessionFactory).getPureSessionFactory(AuthorizationContext.hot);
        } else {
            return sessionFactory;
        }
    }

    /**
     * Constroi o mapa de dependencias
     */
    private void buildDependencyMetaData() {
        this.buildSubclassMapping();
        this.buildBanList();
        for (ClassMetadata classMetaData : this.getMetaDataList()) {
            Type[] types = classMetaData.getPropertyTypes();
            for (int i = 0; i < types.length; i++) {
                if (types[i].isAssociationType()) {
                    this.storeDependencies(classMetaData, (AssociationType) types[i], i);
                }
            }
        }
    }

    /**
     * Constroi a lista de propriedades que podem ser ignoradas na verificacao por dependencias - para, por exemplo, entidades cujo ciclo de
     * vida dependem de uma entidade 'dona'
     */
    private void buildBanList() {
        for (ClassMetadata classMetaData : this.getMetaDataList()) {
            Type[] types = classMetaData.getPropertyTypes();
            SessionFactoryImplementor impl = (SessionFactoryImplementor) this.getSessionFactory();
            for (int i = 0; i < types.length; i++) {
                if (!types[i].isAssociationType()) {
                    continue;
                }
                AssociationType assoc = (AssociationType) types[i];
                boolean hasDelete =
                        this.hasDelete(((AbstractEntityPersister) impl.getEntityPersister(classMetaData.getEntityName()))
                        .getCascadeStyle(i));
                if (!hasDelete) {
                    continue;
                }
                Joinable joinable = assoc.getAssociatedJoinable(impl);
                Class<?> entity;
                String table, columns;
                if (assoc.isEntityType()) {
                    // 1..1 ou N..1
                    final String propName = assoc.getRHSUniqueKeyPropertyName();
                    if (propName == null) {
                        // nao invertido
                        continue;
                    }
                    String associatedEntityName = assoc.getAssociatedEntityName(impl);
                    final ClassMetadata otherMeta = impl.getClassMetadata(associatedEntityName);
                    OuterJoinLoadable loadable = (OuterJoinLoadable) otherMeta;
                    entity = otherMeta.getMappedClass(EntityMode.POJO);
                    table = loadable.getPropertyTableName(propName);
                    columns = Arrays.toString(loadable.getPropertyColumnNames(propName));

                } else if (assoc.isCollectionType()) {
                    CollectionType col = (CollectionType) assoc;
                    CollectionPersister colPersister = impl.getCollectionPersister(col.getRole());
                    Type elementType = colPersister.getElementType();
                    colPersister.isInverse();
                    if (!elementType.isEntityType()) {
                        continue;
                    }
                    // n..M ou 1..N
                    entity = elementType.getReturnedClass();
                    table = joinable.getTableName();
                    columns = Arrays.toString(joinable.getKeyColumnNames());

                } else {
                    continue;
                }
                this.addBanTo(entity, table, columns);

                // banList.add(banPath);
            }
        }
    }

    /**
     * @param entity the entity
     * @param table the table
     * @param columns the cols
     */
    private void addBanTo(Class<?> entity, String table, String columns) {
        this.banList.add(entity.getName() + "." + table + columns);
        if (this.subclassMapping.containsKey(entity)) {
            Collection<Class<?>> collection = this.subclassMapping.get(entity);
            for (Class<?> subName : collection) {
                this.banList.add(subName.getName() + "." + table + columns);
            }
        }
    }

    /**
     * Cria a String que deve ficar na banList
     * 
     * @param otherSide o {@link ClassMetadata} da propriedade
     * @param propertyName nome da propriedade
     * @return {@link String}
     */
    private String createBanListForProperty(ClassMetadata otherSide, String propertyName) {
        if (!(otherSide instanceof OuterJoinLoadable)) {
            throw new IllegalArgumentException("Cannot fetch column names for:" + otherSide.getClass());
        }
        OuterJoinLoadable loadable = (OuterJoinLoadable) otherSide;
        return otherSide.getEntityName() + "." + loadable.getPropertyTableName(propertyName)
                + Arrays.toString(loadable.getPropertyColumnNames(propertyName));
    }

    /**
     * Verifica se um relacionamento contem o cascade delete ou delete-orphan
     * 
     * @param cascadeStyle {@link CascadeStyle}
     * @return boolean
     */
    private boolean hasDelete(CascadeStyle cascadeStyle) {
        return cascadeStyle.hasOrphanDelete() || cascadeStyle.doCascade(CascadingAction.DELETE);
    }

    /**
     * Constroi o mapa de subclasses para adicionar dependencias implicitas a subclasses
     */
    @SuppressWarnings("unchecked")
    private void buildSubclassMapping() {
        Collection<ClassMetadata> meta = this.sessionFactory.getAllClassMetadata().values();
        for (ClassMetadata classMetadata : meta) {
            Class<?> mappedType = classMetadata.getMappedClass(EntityMode.POJO);
            final Class<?> superType = mappedType.getSuperclass();
            this.addTypeCheckSuperType(mappedType, superType);
        }
    }

    /**
     * Adiciona o tipo de mappedType à hierarquia de superType, verificando se a superclasse de superType eh mapeada
     * 
     * @param mappedType the mapped type
     * @param superType the mapped super type
     */
    private void addTypeCheckSuperType(final Class<?> mappedType, final Class<?> superType) {
        if ((mappedType != null) && (superType != null) && (superType != Object.class)
                && (this.sessionFactory.getClassMetadata(superType) != null)) {
            if (!this.subclassMapping.containsKey(superType)) {
                this.subclassMapping.put(superType, new HashSet<Class<?>>());
            }
            this.subclassMapping.get(superType).add(mappedType);
            this.addTypeCheckSuperType(mappedType, superType.getSuperclass());
        }
    }

    /**
     * Extrai a dependencia no {@link AssociationType} do {@link ClassMetadata} passados.
     * 
     * @param classMetaData {@link ClassMetadata}
     * @param type {@link AssociationType}
     * @param columnIndex indice
     */
    private void storeDependencies(final ClassMetadata classMetaData, final AssociationType type, final int columnIndex) {
        if (type.isCollectionType()) {
            // ..toN
            this.storeDependenciesForCollectionType(classMetaData, (CollectionType) type, columnIndex);
        } else {
            // ..to1
            this.storeDependenciesForAssociation(classMetaData, type, columnIndex);
        }
    }

    /**
     * Guarda uma dependencia para uma associacao - N para 1 ou 1 para 1
     * 
     * @param classMetaData o {@link ClassMetadata} dono do relacionamento
     * @param type o tipo do relacionamento
     * @param columnIndex o indice da coluna que mantem o relacionamento
     */
    private void storeDependenciesForAssociation(final ClassMetadata classMetaData, final AssociationType type, final int columnIndex) {
        if (type.getForeignKeyDirection().equals(ForeignKeyDirection.FOREIGN_KEY_TO_PARENT)) {
            // inverse ?-to-1. No need to store dep.
            return;
        }
        String propertyName = classMetaData.getPropertyNames()[columnIndex];
        String banListCheck = this.createBanListForProperty(classMetaData, propertyName);
        if (this.banList.contains(banListCheck)) {
            // property banned
            return;
        }
        Class<?> mappedClass = classMetaData.getMappedClass(EntityMode.POJO);
        Class<?> ownerType = mappedClass;
        Class<?> propertyType = type.getReturnedClass();
        if (ownerType.isAssignableFrom(propertyType)) {
            this.addSelfDependencies(mappedClass, propertyType, propertyName);
        } else {
            this.addDependencies(mappedClass, propertyType, propertyName);
        }
    }

    /**
     * Adiciona uma dependencia para a propria tabela ou uma subclasse dela
     * 
     * @param owner o tipo do dono do relaciomento
     * @param propertyType o tipo da propriedade
     * @param propertyName o nome da propriedade
     */
    private void addSelfDependencies(Class<?> owner, Class<?> propertyType, String propertyName) {
        HibernateEntityDependency dep = new HibernateSelfDependency(owner, propertyType, propertyName);
        this.getListOfDependenciesFor(propertyType.getName()).add(dep);
        if (this.subclassMapping.containsKey(propertyType)) {
            Collection<Class<?>> collection = this.subclassMapping.get(propertyType);
            for (Class<?> subClass : collection) {
                this.addSelfDependencies(owner, subClass, propertyName);
            }
        }
    }

    /**
     * Adiciona uma dependencia
     * 
     * @param owner o 'dono'
     * @param owned a propriedade
     * @param propertyName o nome da propriedade
     */
    private void addDependencies(Class<?> owner, Class<?> owned, String propertyName) {
        HibernateEntityDependency dep = new HibernateEntityDependency(owner, owned, propertyName);
        this.getListOfDependenciesFor(owned.getName()).add(dep);
        if (this.subclassMapping.containsKey(owned)) {
            Collection<Class<?>> collection = this.subclassMapping.get(owned);
            for (Class<?> subClass : collection) {
                dep = new HibernateEntityDependency(owner, subClass, propertyName);
                this.getListOfDependenciesFor(subClass.getName()).add(dep);
            }
        }
    }

    /**
     * Devolve a lista de dependencias para uma entidade ou cria uma nova lista e adiciona ao mapa
     * 
     * @param entityName nome da entidade
     * @return {@link List} de {@link HibernateEntityDependency}
     */
    private List<HibernateEntityDependency> getListOfDependenciesFor(String entityName) {
        List<HibernateEntityDependency> list = this.dependencies.get(entityName);
        if (list == null) {
            list = new ArrayList<HibernateEntityDependency>();
            this.dependencies.put(entityName, list);
        }
        return list;
    }

    /**
     * Guarda dependencias para colecoes, ignorando colecoes de valores e colecoes 'invertidas'
     * 
     * @param classMetaData {@link ClassMetadata}
     * @param type {@link CollectionType}
     * @param columnIndex indice
     */
    private void storeDependenciesForCollectionType(final ClassMetadata classMetaData, final CollectionType type, final int columnIndex) {
        SessionFactoryImplementor impl = (SessionFactoryImplementor) this.getSessionFactory();
        String role = type.getRole();
        CollectionPersister persister = impl.getCollectionPersister(role);
        if (persister.isInverse() || !persister.getElementType().isEntityType()) {
            return;
        }
        Joinable joinable = type.getAssociatedJoinable(impl);
        String banListSuffix = "." + joinable.getTableName() + Arrays.toString(joinable.getKeyColumnNames());
        String banListCheck = classMetaData.getEntityName() + banListSuffix;
        if (this.banList.contains(banListCheck)) {
            // only on many-to-many
            return;
        }
        String elementType = type.getElementType(impl).getName();
        if (this.isArtificialInverse(classMetaData, impl.getClassMetadata(elementType), joinable.getTableName(), joinable
                .getKeyColumnNames())) {
            return;
        }
        String propertyName = classMetaData.getPropertyNames()[columnIndex];
        Class<?> ownerType = classMetaData.getMappedClass(EntityMode.POJO);
        Class<?> elementClass = type.getElementType(impl).getReturnedClass();
        if (ownerType.isAssignableFrom(elementClass)) {
            this.addSelfDependencies(ownerType, elementClass, propertyName);
        } else {
            this.addDependencies(ownerType, elementClass, propertyName);
        }
    }

    /**
     * Verifica se este relacionamento eh um many-to-one invertido <BR>
     * (ou seja, o componente que possui a coluna do relacionamento) <BR>
     * mas sem o 'inverse' marcado.
     * 
     * @param owner o lado que contem o relactionamento
     * @param owned o lado contido na colecao
     * @param tableName o nome da tabela que se faz o join
     * @param columns as colunas que se fazem o join
     * @return boolean
     */
    private boolean isArtificialInverse(ClassMetadata owner, ClassMetadata owned, String tableName, String[] columns) {
        OuterJoinLoadable loadable = (OuterJoinLoadable) owned;
        if (!tableName.equals(loadable.getTableName())) {
            return false;
        }
        Type[] propertyTypes = owned.getPropertyTypes();
        for (int i = 0; i < propertyTypes.length; i++) {
            Type type = propertyTypes[i];
            if (type.isAssociationType()) {
                Class<?> propertyType = type.getReturnedClass();
                if (!propertyType.isAssignableFrom(owner.getMappedClass(EntityMode.POJO))) {
                    continue;
                }
                String[] propertyColumnNames = loadable.getPropertyColumnNames(i);
                if (Arrays.equals(propertyColumnNames, columns)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * @return {@link SessionFactory}
     */
    public SessionFactory getSessionFactory() {
        return this.sessionFactory;
    }

    /**
     * @return {@link SessionFactory}
     */
    public SessionFactory getRealSessionFactory() {
        return this.getSessionFactory();
    }

    /**
     * @param sessionFactory {@link SessionFactory}
     */
    public void setSessionFactory(final SessionFactory sessionFactory) {
        this.sessionFactory = normalizePartitionSessionFactory(sessionFactory);
        this.buildDependencyMetaData();
    }

    /**
     * @return a colecao de {@link ClassMetadata}
     */
    @SuppressWarnings("unchecked")
    private Collection<ClassMetadata> getMetaDataList() {
        return this.sessionFactory.getAllClassMetadata().values();

    }

    /**
     * Devolve a lista de dependencias para uma classe. Nunca devolve 'null'. Modificacoes na lista devolvida podem ou nao modificar esta
     * entidade. Em geral, a lista devolvida deve ser usada apenas para leitura.
     * 
     * @param entityName o nome da entidade
     * @return {@link List}
     */
    public List<? extends HibernateEntityDependency> getDependenciesFor(String entityName) {
        List<HibernateEntityDependency> list = this.dependencies.get(entityName);
        return list == null ? Collections.<HibernateEntityDependency> emptyList() : list;
    }

}
